Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

Follow-up cleanup work for MapStore implementation.

Changes

Extracted WorkspaceListItem component (316 lines)

  • Moved from nested component in ProjectSidebar to standalone file
  • Includes all workspace-specific UI logic and styled components
  • Self-contained with clear responsibilities

Created RenameContext (91 lines)

  • Coordinates rename state across all workspace items ("one at a time" enforcement)
  • Handles the actual rename IPC call
  • Components manage their own local edit state

Reduced ProjectSidebar (897 lines, down from 1210)

  • Removed 313 lines of workspace item logic
  • No longer manages per-workspace rename state
  • Cleaner separation of concerns

Props Reduction

WorkspaceListItem now receives 11 props instead of 18 (-7 props):

Removed:

  • isEditing, editingName, originalName, renameError (now local state)
  • setEditingName (managed internally)
  • startRenaming, confirmRename, handleRenameKeyDown (use context)

Benefits:

  • ✅ Only edited workspace re-renders during rename (not all items)
  • ✅ Better separation: global coordination in context, local state in component
  • ✅ Cleaner interfaces with fewer props
  • ✅ Same UX (still only one workspace can be renamed at a time)

Testing

All 486 tests pass.

Generated with cmux

- Extract WorkspaceListItem into separate file (316 lines)
- Create RenameContext for coordinated rename state (91 lines)
- Reduce ProjectSidebar from 1210 lines to 897 lines (-313 lines)
- Remove 7 props from WorkspaceListItem (18 -> 11 props)

Benefits:
- Only edited workspace re-renders during rename
- Clear separation: global coordination in context, local state in component
- ProjectSidebar no longer manages per-workspace rename state
- WorkspaceListItem self-contained and easier to test

All 486 tests pass.
Problem: ProjectSidebar re-renders on every stream delta because:
- workspaceRecency changes on every delta
- sortedWorkspacesByProject recomputes (new Map)
- All WorkspaceListItems re-render (even though memoized)

Solution: Use useStableReference with compareMaps to only return new
reference when workspace sort order actually changes.

Benefits:
- Zero sidebar re-renders during streaming (unless order changes)
- Custom comparator checks both Map size and workspace path order
- Preserves existing sort-by-recency behavior

All 486 tests pass.
Memoize onClick handler and tooltip title to prevent creating new
function/element references on every parent render.

Before: StatusIndicator re-rendered on every WorkspaceListItem render
because onClick and title props were inline arrow functions and JSX.

After: Only re-renders when actual status values change (streaming,
unread, recencyTimestamp, streamingModel).

Changes:
- WorkspaceListItem: useCallback for handleToggleUnread
- WorkspaceListItem: useMemo for statusTooltipTitle (prevents new JSX)
- StatusIndicator: useCallback for handleClick + React.memo wrapper
## Root Cause
WorkspaceStore.bump(workspaceId) fired on EVERY stream delta, notifying
all subscribers even though sidebar state (canInterrupt, currentModel,
recencyTimestamp) rarely changed. useSyncExternalStore must call the
getter on every notification, causing re-renders even with caching.

## Solution 1: Conditional Bumping
- Added bumpIfSidebarChanged() that only bumps when sidebar fields change
- Stream deltas no longer bump (they don't change sidebar state)
- Extracted extractSidebarState() helper to reduce duplication
- useStableReference in hook provides final equality check layer

## Solution 2: Reduce Prop Drilling
- WorkspaceListItem now receives minimal props (8 instead of 10)
- Removed: workspace object, metadata object
- Added: workspacePath string (single value instead of full object)
- Component accesses stores directly for data it needs

## Results
- Sidebar only re-renders on stream start/end (canInterrupt changes)
- No re-renders during stream deltas (~99% of stream events)
- Cleaner component interface with less data passing
- All 486 tests pass
## Root Cause
WorkspaceStore auto-checked recency on EVERY state bump (including deltas)
via subscribeAny, causing cascade:
  Stream delta → bump → checkRecency → derived.bump → useWorkspaceRecency
  → App re-renders → sortedWorkspacesByProject recomputes
  → ProjectSidebar re-renders → All children flash

## Solution: Explicit Recency Updates
Removed automatic subscribeAny callback. Now explicitly call
checkAndBumpRecencyIfChanged() only after message completion events:
- isCaughtUpMessage (historical load)
- isDeleteMessage (message deleted)
- isStreamEnd (message complete)
- Regular message handling (new message)

NOT called on:
- isStreamDelta (text chunks)
- isStreamStart (stream begin)
- Tool/reasoning deltas

## Defense in Depth: Memoize ProjectSidebar
Added React.memo wrapper to ProjectSidebar as additional protection
against future prop changes.

## Results
- App.tsx no longer re-renders on stream deltas
- sortedWorkspacesByProject stays stable during streaming
- ProjectSidebar doesn't re-render unnecessarily
- WorkspaceListItems only re-render when their own state changes
- Sidebar should be rock solid during streaming

All 486 tests pass
## Root Cause (from React DevTools)
"Props changed: onAddProject, etc. (5 fns)" - Inline arrow functions
and unwrapped hook callbacks were creating new references on every
AppInner render, causing ProjectSidebar to re-render even though memoized.

## Solution
1. Wrapped hook returns in useCallback:
   - useProjectManagement: addProject, removeProject
   - useWorkspaceManagement: removeWorkspace, renameWorkspace

2. Wrapped inline arrow functions in App.tsx:
   - handleAddProjectCallback
   - handleAddWorkspaceCallback
   - handleRemoveProjectCallback

## Before
Every time AppInner rendered → new function refs → ProjectSidebar
saw "props changed" → re-rendered entire sidebar

## After
Functions have stable references → ProjectSidebar props don't change →
no unnecessary re-renders

Test with React DevTools Profiler "Record why each component rendered"
during streaming - should now show zero sidebar re-renders on deltas.
…d hooks

## Root Cause (from React DevTools)
ResumeManager and AutoCompactContinue were using useSyncExternalStore
subscribed to store.subscribe (ALL workspace changes), causing AppInner
to re-render on every workspace state bump, including deltas.

## Problem
These hooks used useSyncExternalStore to watch workspace states:
  useSyncExternalStore(store.subscribe, getSnapshot)

This subscribes to subscribeAny, which fires on EVERY workspace bump.
Even with compareMaps caching, React still re-rendered AppInner because
the subscription fired and React had to call getSnapshot to check.

## Solution: Ref-Based Subscriptions
Changed both hooks from useSyncExternalStore to ref-based subscriptions:
- Store workspace states in a ref (no React state)
- Subscribe to updates but don't trigger re-renders
- Check conditions in subscription callback (side-effects)
- These hooks don't need to render anything, they just react to conditions

Before:
  useSyncExternalStore → subscription fires → React calls getSnapshot →
  AppInner re-renders → checks condition in useEffect

After:
  store.subscribe → update ref → check condition directly →
  no React re-render needed

## Impact
- ResumeManager: Still polls and reacts to events, but silently
- AutoCompactContinue: Still detects compaction, but silently
- AppInner: No longer re-renders from these hooks

Both hooks are pure side-effect hooks - they don't render UI, they just
watch for conditions and trigger actions. Refs are perfect for this.
## Bug Introduced
Previous commit used bumpIfSidebarChanged() for stream deltas, which
only bumped when sidebar state changed. This broke chat streaming!

## Why It Broke
AIView uses useWorkspaceState(workspaceId), which subscribes via
subscribeKey (same as sidebar). When deltas arrived:
1. bumpIfSidebarChanged() checked sidebar state
2. canInterrupt/model/recency unchanged → didn't bump
3. subscribeKey didn't fire → AIView not notified
4. Messages didn't stream in!

## The Fix
ALWAYS bump on stream deltas:
  this.states.bump(workspaceId)

Sidebar still won't re-render because:
- getWorkspaceSidebarState() returns CACHED object when sidebar values
  haven't changed
- useSyncExternalStore gets same object reference → no re-render
- This is the key: cache prevents re-render, not skipping the bump

## Architecture
Both chat and sidebar use same subscription (subscribeKey), but:
- Chat: getWorkspaceState() returns NEW object with updated messages
- Sidebar: getWorkspaceSidebarState() returns CACHED object

The subscription must fire for both to check their caches.

## What We Learned
bumpIfSidebarChanged() is NOT for reducing bumps - it's for checking
when sidebar-specific tracking (previousSidebarValues) should update.
But we must always bump for subscribers to react.
@ammario ammario marked this pull request as ready for review October 14, 2025 17:32
When workspaces are removed, two caches weren't being cleaned up:
- previousSidebarValues (tracks last known sidebar state)
- sidebarStateCache (returns stable object references)

This caused memory to grow over time as workspaces were added/removed.

Added cleanup for both caches in removeWorkspace() to match the
other state cleanup operations.
@ammario ammario changed the title 🤖 MapStore cleanup: Extract WorkspaceListItem and RenameContext 🤖 Optimize sidebar renders Oct 14, 2025
Changed currentModel from `string` to `string | null` throughout the codebase.
Removed hardcoded "claude-sonnet-4-5" defaults which were bug-prone and would
become stale as new models are released.

Benefits:
- No hardcoded model assumptions in the store layer
- Components explicitly handle null (workspace with no messages yet)
- Clearer semantics: null = no model yet, not "use this default"
- Safer: won't silently use wrong model if aggregator state is stale

Updated components to handle null currentModel:
- AIView: Shows "streaming..." if model unknown during interrupt
- useAIViewKeybinds: Skips thinking shortcuts if no model set
- WorkspaceListItem: Already handled null via optional chaining
- App.tsx: Filters out workspaces without models from streamingModels map
- Removed unused Workspace and WorkspaceMetadata imports in WorkspaceListItem
- Moved updateStatesRef inside useEffect to satisfy exhaustive-deps
- Wrapped loadWorkspaceMetadata in useCallback to stabilize dependencies
- Applied prettier formatting
@ammario ammario enabled auto-merge October 14, 2025 17:48
@ammario ammario added this pull request to the merge queue Oct 14, 2025
@ammario ammario removed this pull request from the merge queue due to a manual request Oct 14, 2025
@ammario ammario merged commit 4a4f628 into main Oct 14, 2025
7 checks passed
@ammario ammario deleted the mapstore-cleanup branch October 14, 2025 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants